package com.example.jpa.crud.repository;

import com.example.jpa.crud.configuration.CrudConfiguration;
import com.example.jpa.crud.model.ResourceModel;
import com.example.jpa.crud.search.SearchCondition;
import com.example.jpa.crud.search.SortCondition;
import com.example.jpa.crud.search.expression.ASTNode;
import com.example.jpa.crud.search.expression.SearchExpressionResolver;
import com.example.jpa.crud.search.expression.SortExpressionResolver;

import java.math.BigInteger;
import java.util.*;
import java.util.function.Function;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import javax.transaction.Transactional;

import static java.lang.String.join;

import org.hibernate.metamodel.internal.MetamodelImpl;
import org.hibernate.persister.entity.SingleTableEntityPersister;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.stereotype.Repository;

@Repository
public class SimpleCrudRepositoryForSpringBoot2 implements SimpleCrudRepository {

    private static final Logger log = LoggerFactory.getLogger(SimpleCrudRepositoryForSpringBoot2.class);

    private static final String INSERT_INTO = "insert into";

    private static final String VALUES = "values";

    private static final String SELECT = "select";

    private static final String FROM = "from";

    private static final String WHERE = "where";

    private static final String DELETE_FROM = "delete from";

    private static final String UPDATE = "update";

    private static final String SET = "set";

    private static final String SELECT_COUNT_FROM = "select count(*) from";

    private static final String LIMIT = "limit";

    @PersistenceContext
    private EntityManager entityManager;

    private final NamedParameterJdbcTemplate namedParameterJdbcTemplate;

    private final CrudConfiguration crudConfiguration;

    public SimpleCrudRepositoryForSpringBoot2(
            EntityManager entityManager,
            NamedParameterJdbcTemplate namedParameterJdbcTemplate,
            CrudConfiguration crudConfiguration
    ) {
        this.entityManager = entityManager;
        this.namedParameterJdbcTemplate = namedParameterJdbcTemplate;
        this.crudConfiguration = crudConfiguration;
    }

    private synchronized void init(ResourceModel model) {
        if (model.getColumnToField() == null || model.getFieldToColumn() == null) {
            final Map<String, String> fieldToColumn = new HashMap<>();
            final Map<String, String> columnToField = new HashMap<>();
            final MetamodelImpl metamodel = (MetamodelImpl) entityManager.getMetamodel();
            final SingleTableEntityPersister singleTableEntityPersister = (SingleTableEntityPersister) metamodel.entityPersister(model.getEntityClass());
            final String[] identifierColumnNames = singleTableEntityPersister.getKeyColumnNames();
            final String identifierColumnName = identifierColumnNames.length > 0 ? identifierColumnNames[0] : null;
            fieldToColumn.put(model.getIdName(), identifierColumnName);
            columnToField.put(identifierColumnName, model.getIdName());
            for (String fieldName : singleTableEntityPersister.getPropertyNames()) {
                final String[] columnNames = singleTableEntityPersister.getPropertyColumnNames(fieldName);
                if (columnNames == null || columnNames.length == 0) {
                    continue;
                }
                final String columnName = columnNames[0];
                fieldToColumn.put(fieldName, columnName);
                columnToField.put(columnName, fieldName);
            }
            model.setFieldToColumn(fieldToColumn);
            model.setColumnToField(columnToField);
        }
    }

    private Map<String, String> getFieldToColumn(ResourceModel model) {
        Map<String, String> fieldToColumn = model.getFieldToColumn();
        if (fieldToColumn != null) {
            return fieldToColumn;
        }
        init(model);
        return model.getFieldToColumn();
    }

    private Map<String, String> getColumnToField(ResourceModel model) {
        Map<String, String> columnToField = model.getColumnToField();
        if (columnToField != null) {
            return columnToField;
        }
        init(model);
        return model.getColumnToField();
    }

    private enum IdStrategyType {type0, type1, type2}

    @Transactional
    public Map<String, Object> create(ResourceModel model, Map<String, Object> entity) {
        final Class<?> idClass = model.getIdClass();
        final String idName = model.getIdName();
        IdStrategyType type;
        if (Boolean.FALSE.equals(model.getAutoGenerateId())) {
            type = IdStrategyType.type0;
        } else {
            if (long.class.equals(idClass)
                    || Long.class.equals(idClass)
                    || int.class.equals(idClass)
                    || Integer.class.equals(idClass)
                    || BigInteger.class.equals(idClass)
            ) {
                entity.remove(idName);
                type = IdStrategyType.type1;
            } else {
                final String uuid = UUID.randomUUID().toString().replace("-", "");
                entity.put(idName, uuid);
                type = IdStrategyType.type2;
            }
        }
        final Map<String, String> fieldToColumn = getFieldToColumn(model);
        final List<String> columnNames = new ArrayList<>();
        final List<Object> values = new ArrayList<>();
        if (type == IdStrategyType.type0 || type == IdStrategyType.type2) {
            final String identifierColumnName = fieldToColumn.get(idName);
            if (identifierColumnName == null) {
                log.error("unknown identifierColumnName");
                return null;
            }
            columnNames.add(identifierColumnName);
            values.add(entity.get(idName));
        }
        final Set<String> fieldSet = model.getFields().keySet();
        for (Map.Entry<String, Object> entry : entity.entrySet()) {
            final String key = entry.getKey();
            final Object value = entry.getValue();
            if (key == null) {
                continue;
            }
            if (!fieldSet.contains(key)) {
                continue;
            }
            final String columnName = fieldToColumn.get(key);
            if (columnName == null) {
                continue;
            }
            columnNames.add(columnName);
            values.add(value);
        }
        final List<String> columns = new ArrayList<>(columnNames.size());
        final List<String> placeHolders = new ArrayList<>(columnNames.size());
        for (String columnName : columnNames) {
            columns.add("`" + columnName + "`");
            placeHolders.add(":" + columnName);
        }
        final String sql = INSERT_INTO + " `" + model.getTableName() + "` (" + join(",", columns) + ") "
                + VALUES + " (" + join(",", placeHolders) + ") ";
        if (crudConfiguration.printSql()) {
            log.info(sql);
        }
        final MapSqlParameterSource mapSqlParameterSource = new MapSqlParameterSource();
        for (int i = 0; i < columnNames.size(); i++) {
            final String param = columnNames.get(i);
            final Object value = values.get(i);
            if (crudConfiguration.printSql()) {
                log.info("{} => {}", param, value);
            }
            mapSqlParameterSource.addValue(param, value);
        }
        Map<String, Object> result = null;
        if (type == IdStrategyType.type1) {
            final GeneratedKeyHolder keyHolder = new GeneratedKeyHolder();
            if (namedParameterJdbcTemplate.update(sql, mapSqlParameterSource, keyHolder) > 0) {
                final BigInteger key = keyHolder.getKeyAs(BigInteger.class);
                if (key == null) {
                    entity.put(idName, null);
                } else {
                    if (Long.class.equals(idClass) || long.class.equals(idClass)) {
                        entity.put(idName, key.longValue());
                    } else if (Integer.class.equals(idClass) || int.class.equals(idClass)) {
                        entity.put(idName, key.intValue());
                    } else {
                        entity.put(idName, key);
                    }
                }
                result = entity;
            }
        } else {
            if (namedParameterJdbcTemplate.update(sql, mapSqlParameterSource) > 0) {
                result = entity;
            }
        }
        if (result != null) {
            final Map<String, Function<Object, Object>> visibleFields = model.getVisibleFields();
            final Map<String, Object> map = new HashMap<>(visibleFields.size());
            for (Map.Entry<String, Function<Object, Object>> entry : visibleFields.entrySet()) {
                final String visibleField = entry.getKey();
                final Function<Object, Object> function = entry.getValue();
                map.put(visibleField, function.apply(result.get(visibleField)));
            }
            result = map;
        }
        return result;
    }

    public Map<String, Object> findById(ResourceModel model, Object id) {
        final Map<String, String> fieldToColumn = getFieldToColumn(model);
        final String identifierColumnName = fieldToColumn.get(model.getIdName());
        if (identifierColumnName == null) {
            return null;
        }
        final Map<String, Function<Object, Object>> visibleFields = model.getVisibleFields();
        final List<String> columns = new ArrayList<>(visibleFields.size());
        for (String visibleField : visibleFields.keySet()) {
            String columnName = fieldToColumn.get(visibleField);
            columns.add("`" + columnName + "`");
        }
        final String sql = SELECT + " " + join(",", columns) + " "
                + FROM + " `" + model.getTableName() + "` "
                + WHERE + " `" + identifierColumnName + "` = :" + identifierColumnName;
        if (crudConfiguration.printSql()) {
            log.info(sql);
        }
        final MapSqlParameterSource mapSqlParameterSource = new MapSqlParameterSource();
        if (crudConfiguration.printSql()) {
            log.info("{} => {}", identifierColumnName, id);
        }
        mapSqlParameterSource.addValue(identifierColumnName, id);
        final List<Map<String, Object>> rowList = namedParameterJdbcTemplate.queryForList(sql, mapSqlParameterSource);
        if (rowList.isEmpty()) {
            return null;
        }
        final Map<String, Object> row = rowList.get(0);
        final Map<String, Object> entity = new HashMap<>(row.size());
        final Map<String, String> columnToField = getColumnToField(model);
        for (Map.Entry<String, Object> entry : row.entrySet()) {
            final String key = entry.getKey();
            final String fieldName = columnToField.get(key);
            if (!visibleFields.containsKey(fieldName)) {
                continue;
            }
            final Object value = entry.getValue();
            final Function<Object, Object> function = visibleFields.get(fieldName);
            entity.put(fieldName, function.apply(value));
        }
        return entity;
    }

    @Transactional
    public int deleteById(ResourceModel model, Object id) {
        final Map<String, String> fieldToColumn = getFieldToColumn(model);
        final String identifierColumnName = fieldToColumn.get(model.getIdName());
        if (identifierColumnName == null) {
            return 0;
        }
        final String sql = DELETE_FROM + " `" + model.getTableName() + "` "
                + WHERE + " `" + identifierColumnName + "` = :" + identifierColumnName;
        if (crudConfiguration.printSql()) {
            log.info(sql);
        }
        if (crudConfiguration.printSql()) {
            log.info("{} => {}", identifierColumnName, id);
        }
        final MapSqlParameterSource mapSqlParameterSource = new MapSqlParameterSource();
        mapSqlParameterSource.addValue(identifierColumnName, id);
        return namedParameterJdbcTemplate.update(sql, mapSqlParameterSource);
    }

    @Transactional
    public int updateById(ResourceModel model, Object id, Map<String, Object> entity) {
        final Map<String, String> fieldToColumn = getFieldToColumn(model);
        final String identifierColumnName = fieldToColumn.get(model.getIdName());
        if (identifierColumnName == null) {
            return 0;
        }
        final List<String> columnNames = new ArrayList<>();
        final List<Object> values = new ArrayList<>();
        final Set<String> fieldSet = model.getFields().keySet();
        for (Map.Entry<String, Object> entry : entity.entrySet()) {
            final String key = entry.getKey();
            final Object value = entry.getValue();
            if (key == null) {
                continue;
            }
            if (!fieldSet.contains(key)) {
                continue;
            }
            // TODO
            if (key.equals(model.getIdName())) {
                continue;
            }
            final String columnName = fieldToColumn.get(key);
            if (columnName == null) {
                continue;
            }
            columnNames.add(columnName);
            values.add(value);
        }
        final List<String> strings = new ArrayList<>(columnNames.size());
        for (String columnName : columnNames) {
            strings.add("`" + columnName + "` = :" + columnName);
        }
        final String sql = UPDATE + " `" + model.getTableName() + "` "
                + SET + " " + join(",", strings) + " "
                + WHERE + " `" + identifierColumnName + "` = :" + identifierColumnName;
        if (crudConfiguration.printSql()) {
            log.info(sql);
        }
        final MapSqlParameterSource mapSqlParameterSource = new MapSqlParameterSource();
        for (int i = 0; i < columnNames.size(); i++) {
            final String param = columnNames.get(i);
            final Object value = values.get(i);
            if (crudConfiguration.printSql()) {
                log.info("{} => {}", param, value);
            }
            mapSqlParameterSource.addValue(param, value);
        }
        if (crudConfiguration.printSql()) {
            log.info("{} => {}", identifierColumnName, id);
        }
        mapSqlParameterSource.addValue(identifierColumnName, id);
        return namedParameterJdbcTemplate.update(sql, mapSqlParameterSource);
    }

    public SearchCondition resolveSearchExpression(
            ResourceModel model,
            String searchExpression,
            Map<String, List<String>> params
    ) {
        ASTNode ast = SearchExpressionResolver.resolve(searchExpression);
        if (ast == null) {
            final List<String> conditionList = new ArrayList<>(params.size());
            for (String s : params.keySet()) {
                conditionList.add(" " + s + " eq :" + s + " ");
            }
            ast = SearchExpressionResolver.resolve(join(" and ", conditionList));
        }
        final Map<String, String> fieldToColumn = getFieldToColumn(model);
        return SearchCondition.of(ast, fieldToColumn, params, model.getFields());
    }

    public SortCondition resolveSortExpression(
            ResourceModel model,
            String sortExpression
    ) {
        final List<String[]> list = SortExpressionResolver.resolve(sortExpression);
        final Map<String, String> fieldToColumn = getFieldToColumn(model);
        return SortCondition.of(list, fieldToColumn);
    }

    public Long count(ResourceModel model, SearchCondition searchCondition) {
        final String sql = SELECT_COUNT_FROM + " `" + model.getTableName() + "` " + searchCondition.getSql();
        if (crudConfiguration.printSql()) {
            log.info(sql);
        }
        final MapSqlParameterSource mapSqlParameterSource = new MapSqlParameterSource();
        final Map<String, Object> params = searchCondition.getParams();
        for (Map.Entry<String, Object> entry : params.entrySet()) {
            final String param = entry.getKey();
            final Object value = entry.getValue();
            if (crudConfiguration.printSql()) {
                log.info("{} => {}", param, value);
            }
            mapSqlParameterSource.addValue(param, value);
        }
        return namedParameterJdbcTemplate.queryForObject(sql, mapSqlParameterSource, Long.class);
    }

    public List<Map<String, Object>> search(
            ResourceModel model,
            SearchCondition searchCondition,
            SortCondition sortCondition,
            Long page,
            Long size
    ) {
        final Map<String, String> fieldToColumn = getFieldToColumn(model);
        final Map<String, Function<Object, Object>> visibleFields = model.getVisibleFields();
        final List<String> columns = new ArrayList<>(visibleFields.size());
        for (String visibleField : visibleFields.keySet()) {
            final String columnName = fieldToColumn.get(visibleField);
            columns.add("`" + columnName + "`");
        }
        final String sql = SELECT + " " + join(",", columns)
                + " " + FROM + " `" + model.getTableName() + "` "
                + searchCondition.getSql()
                + sortCondition.getSql()
                + LIMIT + " " + ((page - 1L) * size) + ", " + size;
        if (crudConfiguration.printSql()) {
            log.info(sql);
        }
        final MapSqlParameterSource mapSqlParameterSource = new MapSqlParameterSource();
        final Map<String, Object> params = searchCondition.getParams();
        for (Map.Entry<String, Object> entry : params.entrySet()) {
            final String param = entry.getKey();
            final Object value = entry.getValue();
            if (crudConfiguration.printSql()) {
                log.info("{} => {}", param, value);
            }
            mapSqlParameterSource.addValue(param, value);
        }
        final List<Map<String, Object>> list = namedParameterJdbcTemplate.queryForList(sql, mapSqlParameterSource);
        final List<Map<String, Object>> data = new ArrayList<>(list.size());
        final Map<String, String> columnToField = getColumnToField(model);
        for (Map<String, Object> row : list) {
            final Map<String, Object> entity = new HashMap<>(row.size());
            for (Map.Entry<String, Object> entry : row.entrySet()) {
                final String key = entry.getKey();
                final String fieldName = columnToField.get(key);
                if (!visibleFields.containsKey(fieldName)) {
                    continue;
                }
                final Object value = entry.getValue();
                final Function<Object, Object> function = visibleFields.get(fieldName);
                entity.put(fieldName, function.apply(value));
            }
            data.add(entity);
        }
        return data;
    }

}
